all repos — weimar @ ab494a4c3e2c99426dd9f18f02bcb3fb7ee678a3

Unnamed repository; edit this file 'description' to name the repository.

web/src/routes/image/[id]/+page.svelte (view raw)

  1<script lang="ts">
  2	import { onMount } from 'svelte';
  3	import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api';
  4
  5	let { params } = $props();
  6	let id = $derived(parseInt(params.id));
  7
  8	let img = $state<ImageDetail | null>(null);
  9	let loading = $state(true);
 10	let error = $state('');
 11
 12	let tagInput = $state('');
 13	let suggestions = $state<string[]>([]);
 14	let showSuggestions = $state(false);
 15	let sugIndex = $state(-1);
 16	let tagError = $state('');
 17
 18	onMount(async () => {
 19		try {
 20			img = await getImage(id);
 21		} catch (err) {
 22			error = err instanceof Error ? err.message : 'Failed to load image';
 23		} finally {
 24			loading = false;
 25		}
 26	});
 27
 28	function formatSize(bytes: number): string {
 29		if (bytes < 1024) return bytes + ' B';
 30		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
 31		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
 32	}
 33
 34	function formatDate(s: string): string {
 35		return new Date(s).toLocaleString();
 36	}
 37
 38	async function onTagInput() {
 39		sugIndex = -1;
 40		const q = tagInput.split(',').pop()?.trim() || '';
 41		if (q.length < 1) {
 42			suggestions = [];
 43			showSuggestions = false;
 44			return;
 45		}
 46		try {
 47			const result = await suggestTags(q);
 48			suggestions = result.map((t) => t.tag);
 49			showSuggestions = suggestions.length > 0;
 50		} catch {
 51			suggestions = [];
 52			showSuggestions = false;
 53		}
 54	}
 55
 56	function selectSuggestion(s: string) {
 57		const parts = tagInput.split(',').slice(0, -1);
 58		parts.push(s);
 59		tagInput = parts.join(', ') + ', ';
 60		showSuggestions = false;
 61		sugIndex = -1;
 62	}
 63
 64	async function handleAddTags() {
 65		if (!img) return;
 66		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 67		if (tags.length === 0) return;
 68		tagError = '';
 69		try {
 70			const result = await addTags(img.id, tags);
 71			img.tags_detail = result.tags_detail;
 72			img.tags = result.tags_detail.map((t) => t.tag);
 73			tagInput = '';
 74		} catch (err) {
 75			tagError = err instanceof Error ? err.message : 'Failed to add tags';
 76		}
 77	}
 78
 79	async function handleRemoveTags() {
 80		if (!img) return;
 81		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 82		if (tags.length === 0) return;
 83		tagError = '';
 84		try {
 85			const result = await removeTags(img.id, tags);
 86			img.tags_detail = result.tags_detail;
 87			img.tags = result.tags_detail.map((t) => t.tag);
 88			tagInput = '';
 89		} catch (err) {
 90			tagError = err instanceof Error ? err.message : 'Failed to remove tags';
 91		}
 92	}
 93
 94	// Auto-scroll active suggestion into view.
 95	$effect(() => {
 96		if (sugIndex >= 0) {
 97			const el = document.querySelector('.sug-btn.active') as HTMLElement | null;
 98			if (el) el.scrollIntoView({ block: 'nearest' });
 99		}
100	});
101</script>
102
103{#if loading}
104	<p class="state-msg">Loading...</p>
105{:else if error}
106	<p class="state-msg error">{error}</p>
107{:else if img}
108	<div class="page-head">
109		<h1>{img.filename}</h1>
110		<div class="head-line"></div>
111	</div>
112
113	<div class="layout">
114		<div class="preview">
115			{#if img.thumbnail_path}
116				<img src={imageDownloadUrl(img.id)} alt={img.filename} />
117			{:else}
118				<div class="no-thumb">NO THUMBNAIL</div>
119			{/if}
120		</div>
121
122		<div class="meta">
123			<div class="meta-block uploader-block">
124				<span class="meta-label">UPLOADED BY</span>
125				<div class="uploader-row">
126					{#if img.avatar_url}
127						<img src={img.avatar_url} alt="" class="avatar-img" />
128					{/if}
129					<a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
130				</div>
131			</div>
132			<div class="meta-block">
133				<span class="meta-label">ID</span>
134				<span class="meta-value">#{img.id}</span>
135			</div>
136			<div class="meta-block">
137				<span class="meta-label">DIMENSIONS</span>
138				<span class="meta-value">{img.width} &times; {img.height}</span>
139			</div>
140			<div class="meta-block">
141				<span class="meta-label">FILE SIZE</span>
142				<span class="meta-value">{formatSize(img.file_size)}</span>
143			</div>
144			<div class="meta-block">
145				<span class="meta-label">TYPE</span>
146				<span class="meta-value">{img.mime_type}</span>
147			</div>
148			<div class="meta-block">
149				<span class="meta-label">UPLOADED</span>
150				<span class="meta-value">{formatDate(img.created_at)}</span>
151			</div>
152			<div class="meta-block tags-block">
153				<span class="meta-label">TAGS</span>
154				<div class="tag-pills">
155					{#if img.tags_detail.length > 0 || (img.innate_tags && img.innate_tags.length > 0)}
156						{#each img.tags_detail as t (t.tag)}
157							<a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
158								<span class="tag-name">{t.tag}</span>
159								<span class="tag-count">{t.usage_count}</span>
160							</a>
161						{/each}
162						{#if img.innate_tags}
163							{#each img.innate_tags as t (t)}
164								<a href="/browse?tag={encodeURIComponent(t)}" class="tag-pill innate">
165									<span class="tag-name">{t}</span>
166								</a>
167							{/each}
168						{/if}
169					{:else}
170						<span class="none">none</span>
171					{/if}
172				</div>
173			</div>
174
175			{#if img.is_owner}
176				<div class="meta-block edit-tags-block">
177					<span class="meta-label">EDIT TAGS</span>
178					<div class="tag-input-row">
179						<div class="tag-wrapper">
180							<input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
181								oninput={onTagInput}
182								onkeydown={(e) => {
183									if (e.key === 'Enter') {
184										if (sugIndex >= 0 && sugIndex < suggestions.length) {
185											e.preventDefault();
186											selectSuggestion(suggestions[sugIndex]);
187										}
188									} else if (e.key === 'Tab' || e.key === 'ArrowDown') {
189										if (showSuggestions && suggestions.length > 0) {
190											e.preventDefault();
191											sugIndex = (sugIndex + 1) % suggestions.length;
192										}
193									} else if (e.key === 'ArrowUp') {
194										if (showSuggestions && suggestions.length > 0) {
195											e.preventDefault();
196											sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1;
197										}
198									} else if (e.key === 'Escape') {
199										showSuggestions = false;
200										sugIndex = -1;
201									}
202								}}
203								onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)}
204							/>
205							{#if showSuggestions}
206								<div class="suggestions">
207									{#each suggestions as s, i}
208										<button type="button" class="sug-btn" class:active={i === sugIndex} onmousedown={() => selectSuggestion(s)}>{s}</button>
209									{/each}
210								</div>
211							{/if}
212						</div>
213						<button class="tag-btn add" onclick={handleAddTags}>ADD</button>
214						<button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
215					</div>
216					{#if tagError}
217						<span class="tag-err">{tagError}</span>
218					{/if}
219				</div>
220			{/if}
221
222			<a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
223				DOWNLOAD ORIGINAL
224			</a>
225		</div>
226	</div>
227
228	<p class="back"><a href="/browse">&larr; BACK TO BROWSE</a></p>
229{/if}
230
231<style>
232	.state-msg {
233		padding: 2rem 0;
234		font-size: 1.1rem;
235		color: #888;
236	}
237	.error {
238		color: #c62828;
239	}
240
241	.page-head {
242		margin-bottom: 1.5rem;
243	}
244	.page-head h1 {
245		word-break: break-all;
246	}
247	.head-line {
248		height: 3px;
249		background: #c62828;
250		width: 3rem;
251		margin-top: 0.4rem;
252	}
253
254	.layout {
255		display: flex;
256		gap: 2rem;
257		flex-wrap: wrap;
258	}
259	.preview {
260		flex: 1;
261		min-width: 300px;
262		background: #1a1a1a;
263		display: flex;
264		align-items: center;
265		justify-content: center;
266	}
267	.preview img {
268		display: block;
269		width: 100%;
270		height: auto;
271		max-height: 70vh;
272		object-fit: contain;
273	}
274	.no-thumb {
275		padding: 4rem 2rem;
276		color: #555;
277		font-family: 'Oswald', sans-serif;
278		font-weight: 500;
279		font-size: 0.9rem;
280		letter-spacing: 0.12em;
281	}
282
283	.meta {
284		width: 260px;
285		flex-shrink: 0;
286		display: flex;
287		flex-direction: column;
288		gap: 1rem;
289	}
290	.meta-block {
291		display: flex;
292		flex-direction: column;
293		gap: 0.15rem;
294	}
295	.meta-label {
296		font-family: 'Oswald', sans-serif;
297		font-weight: 500;
298		font-size: 0.7rem;
299		letter-spacing: 0.15em;
300		color: #999;
301	}
302	.meta-value {
303		font-family: 'Inter', sans-serif;
304		font-size: 0.9rem;
305		font-weight: 600;
306		color: #1a1a1a;
307	}
308
309	.uploader-row {
310		display: flex;
311		align-items: center;
312		gap: 0.4rem;
313	}
314	.uploader-link {
315		color: #1a1a1a;
316		text-decoration: none;
317	}
318	.uploader-link:hover {
319		color: #c62828;
320		text-decoration: underline;
321	}
322
323	.avatar-img {
324		width: 1.5rem;
325		height: 1.5rem;
326		object-fit: cover;
327		flex-shrink: 0;
328	}
329
330	.tags-block {
331		flex: 1;
332	}
333	.tag-pills {
334		display: flex;
335		flex-wrap: wrap;
336		gap: 0.35rem;
337	}
338	.tag-pill {
339		display: inline-flex;
340		align-items: center;
341		gap: 0.3rem;
342		background: #1a1a1a;
343		padding: 0.25rem 0.5rem 0.25rem 0.65rem;
344		font-family: 'Inter', sans-serif;
345		font-size: 0.78rem;
346		font-weight: 500;
347		color: #eee;
348		text-decoration: none;
349		transition: background 0.15s;
350	}
351	.tag-pill:hover {
352		background: #c62828;
353		text-decoration: none;
354	}
355	.tag-name {
356		font-weight: 600;
357	}
358	.tag-count {
359		background: rgba(255, 255, 255, 0.12);
360		padding: 0.05rem 0.35rem;
361		font-size: 0.68rem;
362		font-weight: 600;
363		color: rgba(255, 255, 255, 0.65);
364		min-width: 1.1rem;
365		text-align: center;
366	}
367	.tag-pill:hover .tag-count {
368		background: rgba(0, 0, 0, 0.2);
369	}
370	.tag-pill.innate {
371		background: #1565c0;
372	}
373	.tag-pill.innate:hover {
374		background: #1976d2;
375		text-decoration: none;
376	}
377	.tag-pill.innate .tag-count {
378		display: none;
379	}
380	.none {
381		color: #bbb;
382		font-size: 0.85rem;
383	}
384
385	.btn {
386		display: inline-flex;
387		align-items: center;
388		align-self: flex-start;
389		padding: 0.55rem 1.25rem;
390		background: #c62828;
391		color: #fff;
392		border: none;
393		font-family: 'Oswald', sans-serif;
394		font-weight: 500;
395		font-size: 0.8rem;
396		letter-spacing: 0.1em;
397		text-decoration: none;
398		text-transform: uppercase;
399		cursor: pointer;
400		transition: background 0.15s;
401		margin-top: 0.5rem;
402	}
403	.btn:hover {
404		background: #b71c1c;
405		text-decoration: none;
406	}
407
408	.back {
409		margin-top: 2rem;
410	}
411	.back a {
412		font-family: 'Oswald', sans-serif;
413		font-weight: 500;
414		font-size: 0.85rem;
415		letter-spacing: 0.1em;
416	}
417
418	.edit-tags-block {
419		border-top: 2px solid #1a1a1a;
420		padding-top: 0.75rem;
421	}
422	.tag-input-row {
423		display: flex;
424		gap: 0.35rem;
425		align-items: flex-start;
426	}
427	.tag-wrapper {
428		flex: 1;
429		position: relative;
430		min-width: 0;
431	}
432	.tag-wrapper input[type="text"] {
433		width: 100%;
434		padding: 0.4rem 0.5rem;
435		border: 2px solid #1a1a1a;
436		border-radius: 0;
437		font-family: 'Inter', sans-serif;
438		font-size: 0.78rem;
439		font-weight: 500;
440		background: #fff;
441		box-sizing: border-box;
442	}
443	.tag-wrapper input[type="text"]:focus {
444		outline: none;
445		border-color: #c62828;
446	}
447	.suggestions {
448		position: absolute;
449		top: 100%;
450		left: 0;
451		right: 0;
452		background: #fff;
453		border: 2px solid #1a1a1a;
454		border-top: none;
455		margin: 0;
456		padding: 0;
457		z-index: 10;
458		max-height: 130px;
459		overflow-y: auto;
460	}
461	.sug-btn {
462		display: block;
463		width: 100%;
464		text-align: left;
465		padding: 0.35rem 0.5rem;
466		border: none;
467		background: none;
468		font-family: 'Inter', sans-serif;
469		font-size: 0.78rem;
470		font-weight: 500;
471		cursor: pointer;
472		border-radius: 0;
473	}
474	.sug-btn:hover {
475		background: #f0f0f0;
476	}
477	.sug-btn.active {
478		background: #c62828;
479		color: #fff;
480	}
481	.tag-btn {
482		padding: 0.4rem 0.65rem;
483		border: none;
484		font-family: 'Oswald', sans-serif;
485		font-weight: 500;
486		font-size: 0.72rem;
487		letter-spacing: 0.08em;
488		cursor: pointer;
489		white-space: nowrap;
490		transition: background 0.15s;
491	}
492	.tag-btn.add {
493		background: #1a1a1a;
494		color: #fff;
495	}
496	.tag-btn.add:hover {
497		background: #2e7d32;
498	}
499	.tag-btn.remove {
500		background: #1a1a1a;
501		color: #fff;
502	}
503	.tag-btn.remove:hover {
504		background: #c62828;
505	}
506	.tag-err {
507		display: block;
508		margin-top: 0.3rem;
509		color: #c62828;
510		font-family: 'Inter', sans-serif;
511		font-size: 0.72rem;
512		font-weight: 600;
513	}
514</style>